Цель проекта
Исследование проводится по причине убыточности приложения, несмотря на значительный рекламный бюджет в последние месяцы. Главной задачей анализа является формулирование рекомендаций для рекламного отдела с опорой на фактические данные.
Исходные данные
Предоставленные исходные данные: файлы в формате .csv с информацией о визитах и платежах пользователей, а также о рекламных расходах компании.
Исследование проведено по состоянию на 01.11.2019, горизонт анализа -- две недели (согласно БП)
LTV и ROI, а также их динамик в целомCR и RR, а также их динамикLTV и ROI:# pandas
import pandas as pd
pd.options.mode.chained_assignment = None # default='warn'
pd.options.display.float_format = '{:,.2f}'.format
# visualization
import matplotlib.pyplot as plt
import seaborn as sns
# other
import numpy as np
from datetime import datetime, timedelta
# reading from local files
visits = pd.read_csv('/Users/ilatti/Documents/practicum/7_sprint/project/visits_info_short.csv')
orders = pd.read_csv('/Users/ilatti/Documents/practicum/7_sprint/project/orders_info_short.csv')
costs = pd.read_csv('/Users/ilatti/Documents/practicum/7_sprint/project/costs_info_short.csv')
visits.info()
visits.head()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 309901 non-null int64 1 Region 309901 non-null object 2 Device 309901 non-null object 3 Channel 309901 non-null object 4 Session Start 309901 non-null object 5 Session End 309901 non-null object dtypes: int64(1), object(5) memory usage: 14.2+ MB
| User Id | Region | Device | Channel | Session Start | Session End | |
|---|---|---|---|---|---|---|
| 0 | 981449118918 | United States | iPhone | organic | 2019-05-01 02:36:01 | 2019-05-01 02:45:01 |
| 1 | 278965908054 | United States | iPhone | organic | 2019-05-01 04:46:31 | 2019-05-01 04:47:35 |
| 2 | 590706206550 | United States | Mac | organic | 2019-05-01 14:09:25 | 2019-05-01 15:32:08 |
| 3 | 326433527971 | United States | Android | TipTop | 2019-05-01 00:29:59 | 2019-05-01 00:54:25 |
| 4 | 349773784594 | United States | Mac | organic | 2019-05-01 03:33:35 | 2019-05-01 03:57:40 |
orders.info()
orders.head()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 User Id 40212 non-null int64 1 Event Dt 40212 non-null object 2 Revenue 40212 non-null float64 dtypes: float64(1), int64(1), object(1) memory usage: 942.6+ KB
| User Id | Event Dt | Revenue | |
|---|---|---|---|
| 0 | 188246423999 | 2019-05-01 23:09:52 | 4.99 |
| 1 | 174361394180 | 2019-05-01 12:24:04 | 4.99 |
| 2 | 529610067795 | 2019-05-01 11:34:04 | 4.99 |
| 3 | 319939546352 | 2019-05-01 15:34:40 | 4.99 |
| 4 | 366000285810 | 2019-05-01 13:59:51 | 4.99 |
costs.info()
costs.head()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 Channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB
| dt | Channel | costs | |
|---|---|---|---|
| 0 | 2019-05-01 | FaceBoom | 113.30 |
| 1 | 2019-05-02 | FaceBoom | 78.10 |
| 2 | 2019-05-03 | FaceBoom | 85.80 |
| 3 | 2019-05-04 | FaceBoom | 136.40 |
| 4 | 2019-05-05 | FaceBoom | 122.10 |
Вывод
Отформатируем названия столбцов однообразно:
visits.columns = visits.columns.str.lower().str.replace(' ', '_')
orders.columns = orders.columns.str.lower().str.replace(' ', '_')
costs.columns = costs.columns.str.lower().str.replace(' ', '_')
visits.head()
| user_id | region | device | channel | session_start | session_end | |
|---|---|---|---|---|---|---|
| 0 | 981449118918 | United States | iPhone | organic | 2019-05-01 02:36:01 | 2019-05-01 02:45:01 |
| 1 | 278965908054 | United States | iPhone | organic | 2019-05-01 04:46:31 | 2019-05-01 04:47:35 |
| 2 | 590706206550 | United States | Mac | organic | 2019-05-01 14:09:25 | 2019-05-01 15:32:08 |
| 3 | 326433527971 | United States | Android | TipTop | 2019-05-01 00:29:59 | 2019-05-01 00:54:25 |
| 4 | 349773784594 | United States | Mac | organic | 2019-05-01 03:33:35 | 2019-05-01 03:57:40 |
orders.head()
| user_id | event_dt | revenue | |
|---|---|---|---|
| 0 | 188246423999 | 2019-05-01 23:09:52 | 4.99 |
| 1 | 174361394180 | 2019-05-01 12:24:04 | 4.99 |
| 2 | 529610067795 | 2019-05-01 11:34:04 | 4.99 |
| 3 | 319939546352 | 2019-05-01 15:34:40 | 4.99 |
| 4 | 366000285810 | 2019-05-01 13:59:51 | 4.99 |
costs.head()
| dt | channel | costs | |
|---|---|---|---|
| 0 | 2019-05-01 | FaceBoom | 113.30 |
| 1 | 2019-05-02 | FaceBoom | 78.10 |
| 2 | 2019-05-03 | FaceBoom | 85.80 |
| 3 | 2019-05-04 | FaceBoom | 136.40 |
| 4 | 2019-05-05 | FaceBoom | 122.10 |
Также для удобства работы и облегчения поиска неявных дубликатов отформатируем все текстовые поля однообразно
visits.region = visits.region.str.lower().str.replace(' ', '_')
visits.device = visits.device.str.lower().str.replace(' ', '_')
visits.channel = visits.channel.str.lower().str.replace(' ', '_')
visits.head()
| user_id | region | device | channel | session_start | session_end | |
|---|---|---|---|---|---|---|
| 0 | 981449118918 | united_states | iphone | organic | 2019-05-01 02:36:01 | 2019-05-01 02:45:01 |
| 1 | 278965908054 | united_states | iphone | organic | 2019-05-01 04:46:31 | 2019-05-01 04:47:35 |
| 2 | 590706206550 | united_states | mac | organic | 2019-05-01 14:09:25 | 2019-05-01 15:32:08 |
| 3 | 326433527971 | united_states | android | tiptop | 2019-05-01 00:29:59 | 2019-05-01 00:54:25 |
| 4 | 349773784594 | united_states | mac | organic | 2019-05-01 03:33:35 | 2019-05-01 03:57:40 |
costs.channel = costs.channel.str.lower().str.replace(' ', '_')
costs.head()
| dt | channel | costs | |
|---|---|---|---|
| 0 | 2019-05-01 | faceboom | 113.30 |
| 1 | 2019-05-02 | faceboom | 78.10 |
| 2 | 2019-05-03 | faceboom | 85.80 |
| 3 | 2019-05-04 | faceboom | 136.40 |
| 4 | 2019-05-05 | faceboom | 122.10 |
Приведем все поля с датами к типу datetime
visits.session_start = pd.to_datetime(visits.session_start)
visits.session_end = pd.to_datetime(visits.session_end)
orders.event_dt = pd.to_datetime(orders.event_dt)
costs.dt = pd.to_datetime(costs.dt).dt.date
visits.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 309901 entries, 0 to 309900 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 309901 non-null int64 1 region 309901 non-null object 2 device 309901 non-null object 3 channel 309901 non-null object 4 session_start 309901 non-null datetime64[ns] 5 session_end 309901 non-null datetime64[ns] dtypes: datetime64[ns](2), int64(1), object(3) memory usage: 14.2+ MB
orders.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 40212 entries, 0 to 40211 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 40212 non-null int64 1 event_dt 40212 non-null datetime64[ns] 2 revenue 40212 non-null float64 dtypes: datetime64[ns](1), float64(1), int64(1) memory usage: 942.6 KB
costs.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 1800 entries, 0 to 1799 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 dt 1800 non-null object 1 channel 1800 non-null object 2 costs 1800 non-null float64 dtypes: float64(1), object(2) memory usage: 42.3+ KB
Типы данных корректны
visits.region.unique()
array(['united_states', 'uk', 'france', 'germany'], dtype=object)
visits.device.unique()
array(['iphone', 'mac', 'android', 'pc'], dtype=object)
visits.channel.sort_values().unique()
array(['adnonsense', 'faceboom', 'lambdamediaads', 'leapbob',
'mediatornado', 'opplecreativemedia', 'organic', 'rocketsuperads',
'tiptop', 'wahoonetbanner', 'yrabbit'], dtype=object)
costs.channel.sort_values().unique()
array(['adnonsense', 'faceboom', 'lambdamediaads', 'leapbob',
'mediatornado', 'opplecreativemedia', 'rocketsuperads', 'tiptop',
'wahoonetbanner', 'yrabbit'], dtype=object)
Неявных дубликатов в категориальных переменных не выявлено, источники трафика совпадают для архива сессий и данных по тратам (органики нет в затратах, логично)
Поищем явные полные дубликаты в данных
visits.duplicated().sum()
0
orders.duplicated().sum()
0
costs.duplicated().sum()
0
Полных дубликатов не выявлено, попробуем поискать дубликаты в группе столбцов visits
visits['check'] = (visits.user_id.apply(str)
+ visits.session_start.apply(str)
+ visits[['region','device','channel']].sum(axis = 1))
visits.head()
| user_id | region | device | channel | session_start | session_end | check | |
|---|---|---|---|---|---|---|---|
| 0 | 981449118918 | united_states | iphone | organic | 2019-05-01 02:36:01 | 2019-05-01 02:45:01 | 9814491189182019-05-01 02:36:01united_statesip... |
| 1 | 278965908054 | united_states | iphone | organic | 2019-05-01 04:46:31 | 2019-05-01 04:47:35 | 2789659080542019-05-01 04:46:31united_statesip... |
| 2 | 590706206550 | united_states | mac | organic | 2019-05-01 14:09:25 | 2019-05-01 15:32:08 | 5907062065502019-05-01 14:09:25united_statesma... |
| 3 | 326433527971 | united_states | android | tiptop | 2019-05-01 00:29:59 | 2019-05-01 00:54:25 | 3264335279712019-05-01 00:29:59united_statesan... |
| 4 | 349773784594 | united_states | mac | organic | 2019-05-01 03:33:35 | 2019-05-01 03:57:40 | 3497737845942019-05-01 03:33:35united_statesma... |
visits.duplicated(subset='check').sum()
0
visits = visits.drop(columns='check')
visits.head()
| user_id | region | device | channel | session_start | session_end | |
|---|---|---|---|---|---|---|
| 0 | 981449118918 | united_states | iphone | organic | 2019-05-01 02:36:01 | 2019-05-01 02:45:01 |
| 1 | 278965908054 | united_states | iphone | organic | 2019-05-01 04:46:31 | 2019-05-01 04:47:35 |
| 2 | 590706206550 | united_states | mac | organic | 2019-05-01 14:09:25 | 2019-05-01 15:32:08 |
| 3 | 326433527971 | united_states | android | tiptop | 2019-05-01 00:29:59 | 2019-05-01 00:54:25 |
| 4 | 349773784594 | united_states | mac | organic | 2019-05-01 03:33:35 | 2019-05-01 03:57:40 |
Работа с дубликатами проведена, дубликатов не выявлено
orders.boxplot(column='revenue');
plt.title('Диаграмма размаха заказов пользователей \n')
plt.ylabel('Сумма заказа, у.е.')
plt.show()
orders.revenue.describe()
count 40,212.00 mean 5.37 std 3.45 min 4.99 25% 4.99 50% 4.99 75% 4.99 max 49.99 Name: revenue, dtype: float64
orders.revenue.value_counts() / orders.shape[0] * 100
4.99 96.07 5.99 1.94 9.99 0.96 49.99 0.53 19.99 0.51 Name: revenue, dtype: float64
Значения трат пользователей дискретны -- скорее всего, это месячная подписка
orders.groupby('user_id').agg({'revenue':['min', 'max', 'mean', 'count']}).head()
| revenue | ||||
|---|---|---|---|---|
| min | max | mean | count | |
| user_id | ||||
| 599326 | 4.99 | 4.99 | 4.99 | 3 |
| 46006712 | 4.99 | 4.99 | 4.99 | 1 |
| 374052872 | 4.99 | 4.99 | 4.99 | 8 |
| 414791703 | 4.99 | 4.99 | 4.99 | 10 |
| 785077127 | 4.99 | 49.99 | 9.08 | 11 |
orders.query('user_id == 785077127')
| user_id | event_dt | revenue | |
|---|---|---|---|
| 5537 | 785077127 | 2019-06-30 18:35:15 | 4.99 |
| 6173 | 785077127 | 2019-07-04 11:10:57 | 4.99 |
| 6616 | 785077127 | 2019-07-07 22:59:55 | 4.99 |
| 7591 | 785077127 | 2019-07-13 11:52:40 | 4.99 |
| 8450 | 785077127 | 2019-07-19 18:45:57 | 4.99 |
| 11425 | 785077127 | 2019-08-08 22:50:25 | 4.99 |
| 15515 | 785077127 | 2019-09-01 00:52:52 | 4.99 |
| 15896 | 785077127 | 2019-09-03 15:39:51 | 4.99 |
| 19585 | 785077127 | 2019-09-23 16:03:56 | 4.99 |
| 20622 | 785077127 | 2019-09-28 08:54:05 | 49.99 |
| 21070 | 785077127 | 2019-09-30 00:32:03 | 4.99 |
Гипотеза не подтвердилась, траты не раз в месяц, это некие платные действия; оставляем значения как есть
costs.boxplot(column='costs');
plt.title('Диаграмма размаха затрат на рекламу \n')
plt.ylabel('Сумма заказа, у.е.')
plt.show()
costs.costs.describe()
count 1,800.00 mean 58.61 std 107.74 min 0.80 25% 6.50 50% 12.29 75% 33.60 max 630.00 Name: costs, dtype: float64
CACdef get_profiles(sessions, orders, events, ad_costs, event_names=[]):
# находим параметры первых посещений
profiles = (
sessions.sort_values(by=['user_id', 'session_start'])
.groupby('user_id')
.agg(
{
'session_start': 'first',
'channel': 'first',
'device': 'first',
'region': 'first',
}
)
.rename(columns={'session_start': 'first_ts'})
.reset_index()
)
# для когортного анализа определяем дату первого посещения
# и первый день месяца, в который это посещение произошло
profiles['dt'] = profiles['first_ts'].dt.date
profiles['month'] = profiles['first_ts'].values.astype('datetime64[M]')
# добавляем признак платящих пользователей
profiles['payer'] = profiles['user_id'].isin(orders['user_id'].unique())
# добавляем флаги для всех событий из event_names
for event in event_names:
if event in events['event_name'].unique():
profiles[event] = profiles['user_id'].isin(
events.query('event_name == @event')['user_id'].unique()
)
# считаем количество уникальных пользователей
# с одинаковыми источником и датой привлечения
new_users = (
profiles.groupby(['dt', 'channel'])
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'unique_users'})
.reset_index()
)
# объединяем траты на рекламу и число привлечённых пользователей
ad_costs = ad_costs.merge(new_users, on=['dt', 'channel'], how='left')
# делим рекламные расходы на число привлечённых пользователей
ad_costs['acquisition_cost'] = ad_costs['costs'] / ad_costs['unique_users']
# добавляем стоимость привлечения в профили
profiles = profiles.merge(
ad_costs[['dt', 'channel', 'acquisition_cost']],
on=['dt', 'channel'],
how='left',
)
# стоимость привлечения органических пользователей равна нулю
profiles['acquisition_cost'] = profiles['acquisition_cost'].fillna(0)
return profiles
def get_retention(
profiles,
sessions,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# добавляем столбец payer в передаваемый dimensions список
dimensions = ['payer'] + dimensions
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# собираем «сырые» данные для расчёта удержания
result_raw = result_raw.merge(
sessions[['user_id', 'session_start']], on='user_id', how='left'
)
result_raw['lifetime'] = (
result_raw['session_start'] - result_raw['first_ts']
).dt.days
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу удержания
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# получаем таблицу динамики удержания
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
def get_conversion(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# определяем дату и время первой покупки для каждого пользователя
first_purchases = (
purchases.sort_values(by=['user_id', 'event_dt'])
.groupby('user_id')
.agg({'event_dt': 'first'})
.reset_index()
)
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
first_purchases[['user_id', 'event_dt']], on='user_id', how='left'
)
# рассчитываем лайфтайм для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция для группировки таблицы по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
result = df.pivot_table(
index=dims, columns='lifetime', values='user_id', aggfunc='nunique'
)
result = result.fillna(0).cumsum(axis = 1)
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# делим каждую «ячейку» в строке на размер когорты
# и получаем conversion rate
result = result.div(result['cohort_size'], axis=0)
result = result[['cohort_size'] + list(range(horizon_days))]
result['cohort_size'] = cohort_sizes
return result
# получаем таблицу конверсии
result_grouped = group_by_dimensions(result_raw, dimensions, horizon_days)
# для таблицы динамики конверсии убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицу динамики конверсии
result_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
# возвращаем обе таблицы и сырые данные
return result_raw, result_grouped, result_in_time
LTV и ROI¶def get_ltv(
profiles,
purchases,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
):
# исключаем пользователей, не «доживших» до горизонта анализа
last_suitable_acquisition_date = observation_date
if not ignore_horizon:
last_suitable_acquisition_date = observation_date - timedelta(
days=horizon_days - 1
)
result_raw = profiles.query('dt <= @last_suitable_acquisition_date')
# добавляем данные о покупках в профили
result_raw = result_raw.merge(
purchases[['user_id', 'event_dt', 'revenue']], on='user_id', how='left'
)
# рассчитываем лайфтайм пользователя для каждой покупки
result_raw['lifetime'] = (
result_raw['event_dt'] - result_raw['first_ts']
).dt.days
# группируем по cohort, если в dimensions ничего нет
if len(dimensions) == 0:
result_raw['cohort'] = 'All users'
dimensions = dimensions + ['cohort']
# функция группировки по желаемым признакам
def group_by_dimensions(df, dims, horizon_days):
# строим «треугольную» таблицу выручки
result = df.pivot_table(
index=dims, columns='lifetime', values='revenue', aggfunc='sum'
)
# находим сумму выручки с накоплением
result = result.fillna(0).cumsum(axis=1)
# вычисляем размеры когорт
cohort_sizes = (
df.groupby(dims)
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'})
)
# объединяем размеры когорт и таблицу выручки
result = cohort_sizes.merge(result, on=dims, how='left').fillna(0)
# считаем LTV: делим каждую «ячейку» в строке на размер когорты
result = result.div(result['cohort_size'], axis=0)
# исключаем все лайфтаймы, превышающие горизонт анализа
result = result[['cohort_size'] + list(range(horizon_days))]
# восстанавливаем размеры когорт
result['cohort_size'] = cohort_sizes
# собираем датафрейм с данными пользователей и значениями CAC,
# добавляя параметры из dimensions
cac = df[['user_id', 'acquisition_cost'] + dims].drop_duplicates()
# считаем средний CAC по параметрам из dimensions
cac = (
cac.groupby(dims)
.agg({'acquisition_cost': 'mean'})
.rename(columns={'acquisition_cost': 'cac'})
)
# считаем ROI: делим LTV на CAC
roi = result.div(cac['cac'], axis=0)
# удаляем строки с бесконечным ROI
roi = roi[~roi['cohort_size'].isin([np.inf])]
# восстанавливаем размеры когорт в таблице ROI
roi['cohort_size'] = cohort_sizes
# добавляем CAC в таблицу ROI
roi['cac'] = cac['cac']
# в финальной таблице оставляем размеры когорт, CAC
# и ROI в лайфтаймы, не превышающие горизонт анализа
roi = roi[['cohort_size', 'cac'] + list(range(horizon_days))]
# возвращаем таблицы LTV и ROI
return result, roi
# получаем таблицы LTV и ROI
result_grouped, roi_grouped = group_by_dimensions(
result_raw, dimensions, horizon_days
)
# для таблиц динамики убираем 'cohort' из dimensions
if 'cohort' in dimensions:
dimensions = []
# получаем таблицы динамики LTV и ROI
result_in_time, roi_in_time = group_by_dimensions(
result_raw, dimensions + ['dt'], horizon_days
)
return (
result_raw, # сырые данные
result_grouped, # таблица LTV
result_in_time, # таблица динамики LTV
roi_grouped, # таблица ROI
roi_in_time, # таблица динамики ROI
)
def filter_data(df, window):
# для каждого столбца применяем скользящее среднее
for column in df.columns.values:
df[column] = df[column].rolling(window).mean()
return df
def plot_retention(retention, retention_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 10))
# исключаем размеры когорт и удержание первого дня
retention = retention.drop(columns=['cohort_size', 0])
# в таблице динамики оставляем только нужный лайфтайм
retention_history = retention_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# если в индексах таблицы удержания только payer,
# добавляем второй признак — cohort
if retention.index.nlevels == 1:
retention['cohort'] = 'All users'
retention = retention.reset_index().set_index(['cohort', 'payer'])
# в таблице графиков — два столбца и две строки, четыре ячейки
# в первой строим кривые удержания платящих пользователей
ax1 = plt.subplot(2, 2, 1)
retention.query('payer == True').droplevel('payer').T.plot(
grid=True, ax=ax1
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание платящих пользователей')
# во второй ячейке строим кривые удержания неплатящих
# вертикальная ось — от графика из первой ячейки
ax2 = plt.subplot(2, 2, 2, sharey=ax1)
retention.query('payer == False').droplevel('payer').T.plot(
grid=True, ax=ax2
)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Удержание неплатящих пользователей')
# в третьей ячейке — динамика удержания платящих
ax3 = plt.subplot(2, 2, 3)
# получаем названия столбцов для сводной таблицы
columns = [
name
for name in retention_history.index.names
if name not in ['dt', 'payer']
]
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == True').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания платящих пользователей на {}-й день'.format(
horizon
)
)
# в чётвертой ячейке — динамика удержания неплатящих
ax4 = plt.subplot(2, 2, 4, sharey=ax3)
# фильтруем данные и строим график
filtered_data = retention_history.query('payer == False').pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax4)
plt.xlabel('Дата привлечения')
plt.title(
'Динамика удержания неплатящих пользователей на {}-й день'.format(
horizon
)
)
plt.tight_layout()
plt.show()
def plot_conversion(conversion, conversion_history, horizon, window=7):
# задаём размер сетки для графиков
plt.figure(figsize=(15, 5))
# исключаем размеры когорт
conversion = conversion.drop(columns=['cohort_size'])
# в таблице динамики оставляем только нужный лайфтайм
conversion_history = conversion_history.drop(columns=['cohort_size'])[
[horizon - 1]
]
# первый график — кривые конверсии
ax1 = plt.subplot(1, 2, 1)
conversion.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('Конверсия пользователей')
# второй график — динамика конверсии
ax2 = plt.subplot(1, 2, 2, sharey=ax1)
columns = [
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
name for name in conversion_history.index.names if name not in ['dt']
]
filtered_data = conversion_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика конверсии пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
LTV и ROI¶def plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon, window=7):
# задаём сетку отрисовки графиков
plt.figure(figsize=(20, 10))
# из таблицы ltv исключаем размеры когорт
ltv = ltv.drop(columns=['cohort_size'])
# в таблице динамики ltv оставляем только нужный лайфтайм
ltv_history = ltv_history.drop(columns=['cohort_size'])[[horizon - 1]]
# стоимость привлечения запишем в отдельный фрейм
cac_history = roi_history[['cac']]
# из таблицы roi исключаем размеры когорт и cac
roi = roi.drop(columns=['cohort_size', 'cac'])
# в таблице динамики roi оставляем только нужный лайфтайм
roi_history = roi_history.drop(columns=['cohort_size', 'cac'])[
[horizon - 1]
]
# первый график — кривые ltv
ax1 = plt.subplot(2, 3, 1)
ltv.T.plot(grid=True, ax=ax1)
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('LTV')
# второй график — динамика ltv
ax2 = plt.subplot(2, 3, 2, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in ltv_history.index.names if name not in ['dt']]
filtered_data = ltv_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax2)
plt.xlabel('Дата привлечения')
plt.title('Динамика LTV пользователей на {}-й день'.format(horizon))
# третий график — динамика cac
ax3 = plt.subplot(2, 3, 3, sharey=ax1)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in cac_history.index.names if name not in ['dt']]
filtered_data = cac_history.pivot_table(
index='dt', columns=columns, values='cac', aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax3)
plt.xlabel('Дата привлечения')
plt.title('Динамика стоимости привлечения пользователей')
# четвёртый график — кривые roi
ax4 = plt.subplot(2, 3, 4)
roi.T.plot(grid=True, ax=ax4)
plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
plt.legend()
plt.xlabel('Лайфтайм')
plt.title('ROI')
# пятый график — динамика roi
ax5 = plt.subplot(2, 3, 5, sharey=ax4)
# столбцами сводной таблицы станут все столбцы индекса, кроме даты
columns = [name for name in roi_history.index.names if name not in ['dt']]
filtered_data = roi_history.pivot_table(
index='dt', columns=columns, values=horizon - 1, aggfunc='mean'
)
filter_data(filtered_data, window).plot(grid=True, ax=ax5)
plt.axhline(y=1, color='red', linestyle='--', label='Уровень окупаемости')
plt.xlabel('Дата привлечения')
plt.title('Динамика ROI пользователей на {}-й день'.format(horizon))
plt.tight_layout()
plt.show()
Применим функцию:
events=None
profiles = get_profiles(visits, orders, events, costs)
profiles.head()
| user_id | first_ts | channel | device | region | dt | month | payer | acquisition_cost | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 599326 | 2019-05-07 20:58:57 | faceboom | mac | united_states | 2019-05-07 | 2019-05-01 | True | 1.09 |
| 1 | 4919697 | 2019-07-09 12:46:07 | faceboom | iphone | united_states | 2019-07-09 | 2019-07-01 | False | 1.11 |
| 2 | 6085896 | 2019-10-01 09:58:33 | organic | iphone | france | 2019-10-01 | 2019-10-01 | False | 0.00 |
| 3 | 22593348 | 2019-08-22 21:35:48 | adnonsense | pc | germany | 2019-08-22 | 2019-08-01 | False | 0.99 |
| 4 | 31989216 | 2019-10-02 00:07:44 | yrabbit | iphone | united_states | 2019-10-02 | 2019-10-01 | False | 0.23 |
print(f'Минимальная дата привлечения пользователя: {profiles.dt.min()}')
print(f'Максимальная дата привлечения пользователя: {profiles.dt.max()}')
Минимальная дата привлечения пользователя: 2019-05-01 Максимальная дата привлечения пользователя: 2019-10-27
profiles.user_id.count()
150008
Вывод
(
profiles.groupby('region')
.agg(
payers=('payer', 'sum'),
users=('user_id', 'count'),
p_u_ratio=('payer', 'mean'),
)
.sort_values(by='p_u_ratio', ascending=False)
.style.format("{:,.3f}")
)
| payers | users | p_u_ratio | |
|---|---|---|---|
| region | |||
| united_states | 6,902.000 | 100,002.000 | 0.069 |
| germany | 616.000 | 14,981.000 | 0.041 |
| uk | 700.000 | 17,575.000 | 0.040 |
| france | 663.000 | 17,450.000 | 0.038 |
Вывод
(
profiles.groupby('device')
.agg(
payers=('payer', 'sum'),
users=('user_id', 'count'),
p_u_ratio=('payer', 'mean'),
)
.sort_values(by='p_u_ratio', ascending=False)
.style.format("{:,.3f}")
)
| payers | users | p_u_ratio | |
|---|---|---|---|
| device | |||
| mac | 1,912.000 | 30,042.000 | 0.064 |
| iphone | 3,382.000 | 54,479.000 | 0.062 |
| android | 2,050.000 | 35,032.000 | 0.059 |
| pc | 1,537.000 | 30,455.000 | 0.050 |
Вывод
iphone, android, mac и pc;iphone;iphone, однако, доля платящих пользователей для mac несколько выше.channel_CR = (
profiles.groupby('channel')
.agg(
payers=('payer', 'sum'),
users=('user_id', 'count'),
p_u_ratio=('payer', 'mean'),
)
.sort_values(by='p_u_ratio', ascending=False)
)
channel_CR
| payers | users | p_u_ratio | |
|---|---|---|---|
| channel | |||
| faceboom | 3557 | 29144 | 0.12 |
| adnonsense | 440 | 3880 | 0.11 |
| lambdamediaads | 225 | 2149 | 0.10 |
| tiptop | 1878 | 19561 | 0.10 |
| rocketsuperads | 352 | 4448 | 0.08 |
| wahoonetbanner | 453 | 8553 | 0.05 |
| yrabbit | 165 | 4312 | 0.04 |
| mediatornado | 156 | 4364 | 0.04 |
| leapbob | 262 | 8553 | 0.03 |
| opplecreativemedia | 233 | 8605 | 0.03 |
| organic | 1160 | 56439 | 0.02 |
Вывод
faceboom.iphone, android, mac и pc, в абсолютном выражении у самых качественных пользователей iphone, а в относительном -- mac, наименее качественные пользователи pc;faceboom.Найдем общую сумму затрат на маркетинг
marketing_total_exp = round(costs.costs.sum())
print(f'Общие затраты на маркетинг за рассматриваемый период {marketing_total_exp} у.е.')
Общие затраты на маркетинг за рассматриваемый период 105497 у.е.
marketing_channel_exp = (
costs.groupby('channel')['costs']
.sum()
.sort_values(ascending=False)
.reset_index()
)
marketing_channel_exp.columns=['channel', 'total_expenses']
marketing_channel_exp = marketing_channel_exp.merge(channel_CR, on='channel', how='left')
pd.options.display.float_format = '{:,.3f}'.format
marketing_channel_exp.sort_values(by='p_u_ratio', ascending=False)
| channel | total_expenses | payers | users | p_u_ratio | |
|---|---|---|---|---|---|
| 1 | faceboom | 32,445.600 | 3557 | 29144 | 0.122 |
| 3 | adnonsense | 3,911.250 | 440 | 3880 | 0.113 |
| 7 | lambdamediaads | 1,557.600 | 225 | 2149 | 0.105 |
| 0 | tiptop | 54,751.300 | 1878 | 19561 | 0.096 |
| 5 | rocketsuperads | 1,833.000 | 352 | 4448 | 0.079 |
| 2 | wahoonetbanner | 5,151.000 | 453 | 8553 | 0.053 |
| 9 | yrabbit | 944.220 | 165 | 4312 | 0.038 |
| 8 | mediatornado | 954.480 | 156 | 4364 | 0.036 |
| 6 | leapbob | 1,797.600 | 262 | 8553 | 0.031 |
| 4 | opplecreativemedia | 2,151.250 | 233 | 8605 | 0.027 |
source_vs_exp = (marketing_channel_exp
.groupby('channel', as_index=False)['total_expenses']
.sum()
.sort_values(by='total_expenses',
ascending=False)
.reset_index(drop=True))
other_channels = source_vs_exp.loc[5:, 'total_expenses'].sum()
source_vs_exp = source_vs_exp.loc[:4, :]
source_vs_exp.loc[5, 'channel'] = 'others'
source_vs_exp.loc[5, 'total_expenses'] = other_channels
fig, ax = plt.subplots()
fig.set(tight_layout=True);
colors = sns.color_palette('deep')
ax.pie(source_vs_exp.total_expenses, labels=source_vs_exp.channel, colors=colors, autopct='%1.0f%%')
ax.set(ylabel=None, title='Распределение суммарных затрат по привлечению трафика')
plt.show()
Больше всего средств было потрачено на продвижение через tiptop, однако, историческая конверсия в этом источнике на четвертом месте
costs_fig = costs.copy()
costs_fig['month'] = pd.to_datetime(costs_fig['dt']).dt.month
costs_fig['week'] = pd.to_datetime(costs_fig['dt']).dt.isocalendar().week
fig, ax = plt.subplots()
fig.set_size_inches(10, 7)
for i in costs_fig['channel'].unique():
pivot_i = (costs_fig.query('channel == @i')
.pivot_table(index='month',
values='costs',
aggfunc='sum'))
ax.plot(pivot_i.index, pivot_i['costs'], label = i)
ax.set_title('Месячная динамика расходов по источникам трафика \n')
ax.set_ylabel('Расходы на трафик, у.е.')
ax.set_xlabel('Месяц')
ax.legend()
plt.show;
plt.draw()
plt.pause(0.001)
Больше всего вкладывается денег в покупку трафика у tiptop и faceboom, при этом в tiptop вкладывается все больше и больше денег, а вливания в faceboom примерно на одном уровне с июля 2019
fig, ax = plt.subplots()
fig.set_size_inches(10, 7)
for i in costs_fig['channel'].unique():
pivot_i = (costs_fig.query('channel == @i')
.pivot_table(index='week',
values='costs',
aggfunc='sum'))
ax.plot(pivot_i.index, pivot_i['costs'], label = i)
ax.set_title('Недельная динамика расходов по источникам трафика \n')
ax.set_ylabel('Расходы на трафик, у.е.')
ax.set_xlabel('Неделя')
ax.legend()
plt.show;
plt.draw()
plt.pause(0.001)
Рассчитаем среднюю стоимость привлечения пользователя для разных источников трафика:
marketing_channel_exp['cac'] = marketing_channel_exp.total_expenses / marketing_channel_exp.users
marketing_channel_exp.sort_values(by='cac', ascending=False)
| channel | total_expenses | payers | users | p_u_ratio | cac | |
|---|---|---|---|---|---|---|
| 0 | tiptop | 54,751.300 | 1878 | 19561 | 0.096 | 2.799 |
| 1 | faceboom | 32,445.600 | 3557 | 29144 | 0.122 | 1.113 |
| 3 | adnonsense | 3,911.250 | 440 | 3880 | 0.113 | 1.008 |
| 7 | lambdamediaads | 1,557.600 | 225 | 2149 | 0.105 | 0.725 |
| 2 | wahoonetbanner | 5,151.000 | 453 | 8553 | 0.053 | 0.602 |
| 5 | rocketsuperads | 1,833.000 | 352 | 4448 | 0.079 | 0.412 |
| 4 | opplecreativemedia | 2,151.250 | 233 | 8605 | 0.027 | 0.250 |
| 9 | yrabbit | 944.220 | 165 | 4312 | 0.038 | 0.219 |
| 8 | mediatornado | 954.480 | 156 | 4364 | 0.036 | 0.219 |
| 6 | leapbob | 1,797.600 | 262 | 8553 | 0.031 | 0.210 |
marketing_channel_exp.boxplot(column='cac');
plt.title('Диаграмма размаха CAC \n')
plt.ylabel('CAC, у.е.')
plt.show()
marketing_channel_exp.cac.describe()
count 10.000 mean 0.756 std 0.792 min 0.210 25% 0.227 50% 0.507 75% 0.937 max 2.799 Name: cac, dtype: float64
Средняя стоимость привлечения одного пользователя через tiptop превышает этот же показатель у faceboom -- ближайшего конкурента по CAC более, чем в 2.5 раза
Можно разделить источники трафика по стоимости привлечения клиента на основании статистического распределения:
leapbob, mediatornado, yrabbitopplecreativemedia rocketsuperadswahoonetbanner lambdamediaadsadnonsense faceboomtiptopВведем критерий подобия: разделим историческую конверсию пользователей на исторический CAC и отсортируем источники трафика по этому критерию
marketing_channel_exp['cr_cac_ratio'] = marketing_channel_exp.p_u_ratio / marketing_channel_exp.cac
marketing_channel_exp.sort_values(by='cr_cac_ratio', ascending=False).reset_index().drop(columns='index')
| channel | total_expenses | payers | users | p_u_ratio | cac | cr_cac_ratio | |
|---|---|---|---|---|---|---|---|
| 0 | rocketsuperads | 1,833.000 | 352 | 4448 | 0.079 | 0.412 | 0.192 |
| 1 | yrabbit | 944.220 | 165 | 4312 | 0.038 | 0.219 | 0.175 |
| 2 | mediatornado | 954.480 | 156 | 4364 | 0.036 | 0.219 | 0.163 |
| 3 | leapbob | 1,797.600 | 262 | 8553 | 0.031 | 0.210 | 0.146 |
| 4 | lambdamediaads | 1,557.600 | 225 | 2149 | 0.105 | 0.725 | 0.144 |
| 5 | adnonsense | 3,911.250 | 440 | 3880 | 0.113 | 1.008 | 0.112 |
| 6 | faceboom | 32,445.600 | 3557 | 29144 | 0.122 | 1.113 | 0.110 |
| 7 | opplecreativemedia | 2,151.250 | 233 | 8605 | 0.027 | 0.250 | 0.108 |
| 8 | wahoonetbanner | 5,151.000 | 453 | 8553 | 0.053 | 0.602 | 0.088 |
| 9 | tiptop | 54,751.300 | 1878 | 19561 | 0.096 | 2.799 | 0.034 |
Следует рассмотреть показатель ROI и его динамику для более уверенных выводов, но на текущем этапе можно заметить, что исходя из исторической конверсии пользователей и исторической средней стоимости их привлечения, два источника трафика, в которых сосредоточено большинство маркетинговых трат -- tiptop и faceboom, не являются самыми эффективными из представленных
tiptop и faceboom, при этом чуть больше половины всех средств ушло на продвижение через tiptoptiptop -- этот источник трафика демонстрирует стремительный рост затрат в течение рассматриваемого периодаtiptop и faceboom, при это для tiptop этот показатель в 2.5 раза выше в сравнении с faceboomleapbob, mediatornado и yrabbitCAC, требуемого для привлечения этих пользователей, tiptop и faceboom находятся в худшей половине, tiptop находится на последнем местеROI и его динамикуobservation_date = datetime(2019, 11, 1).date()
horizon_days = 14
#исключим органических пользователей для ликвидации искажения общей картины
profiles_full = profiles.copy()
visits_full = visits.copy()
profiles = profiles.query('channel != "organic"')
visits = visits.query('channel != "organic"')
raw, ltv, ltv_history, roi, roi_history = get_ltv(
profiles,
orders,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
)
plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon_days, window=7)
LTV постепенно растет и к концу второй недели достигает значения в ~ 0.9 у.е. на пользователяLTV двух недель демонстрирует сезонность, однако достаточно стабильнаCAC демонстрирует резкий рост в конце мая-начале июня с последующим умеренным ростом -- вероятно, это связано с увеличением затрат на привлечение трафика из самого дорогого источника -- tiptopROI постепенно растет и к концу второй недели едва пересекает отметку в 80 % -- инвестиции в рекламу убыточныROI двух недель показывает, что в мае маркетинговые инвестиции окупались, как и в середине июня, но тренд, намеченный в начале июня сохранился и продолжился с конца июня -- инвестиции стали убыточны; выход в плюс в середине июня объясняется сезонностью -- в этот период динамика LTV также демонстрирует ростraw_ret, retention, retention_history = get_retention(
profiles,
visits,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
)
plot_retention(retention, retention_history, horizon_days, window=7)
0.1raw_ret, retention, retention_history = get_retention(
profiles,
visits,
observation_date,
horizon_days,
dimensions=['device'],
ignore_horizon=False,
)
plot_retention(retention, retention_history, horizon_days, window=7)
pc выше конкурентов, конкуренты примерно на одном уровнеpc в некоторых когортахraw_ret, retention, retention_history = get_retention(
profiles,
visits,
observation_date,
horizon_days,
dimensions=['region'],
ignore_horizon=False,
)
plot_retention(retention, retention_history, horizon_days, window=7)
raw_ret, retention, retention_history = get_retention(
profiles,
visits,
observation_date,
horizon_days,
dimensions=['channel'],
ignore_horizon=False,
)
plot_retention(retention, retention_history, horizon_days, window=7)
faceboom и adnonsense, остальные источники располагаются в общем семействе кривыхraw, conversion, conversion_history = get_conversion(
profiles,
orders,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
)
plot_conversion(conversion, conversion_history, horizon_days, window=7)
0.08raw, conversion, conversion_history = get_conversion(
profiles,
orders,
observation_date,
horizon_days,
dimensions=['device'],
ignore_horizon=False,
)
plot_conversion(conversion, conversion_history, horizon_days, window=7)
pc, лучше всего у пользователей mac и iphone, android занимает промежуточное положение, ближе к лидерамraw, conversion, conversion_history = get_conversion(
profiles,
orders,
observation_date,
horizon_days,
dimensions=['region'],
ignore_horizon=False,
)
plot_conversion(conversion, conversion_history, horizon_days, window=7)
raw, conversion, conversion_history = get_conversion(
profiles,
orders,
observation_date,
horizon_days,
dimensions=['channel'],
ignore_horizon=False,
)
plot_conversion(conversion, conversion_history, horizon_days, window=7)
faceboom, adnonsense и lambdamediaadsopplecreativemedia, leapbob и yrabbitraw, ltv, ltv_history, roi, roi_history = get_ltv(
profiles,
orders,
observation_date,
horizon_days,
dimensions=['device'],
ignore_horizon=False,
)
plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon_days, window=14)
Проверим источники трафика новых пользователей для разных платформ
(profiles.query('device in ["mac", "iphone"]')
.groupby('channel')['user_id']
.count()
.plot(kind='pie',
subplots=True,
autopct='%1.0f%%',
colors = colors,
title='Распределение пользователей mac и iphone по источникам трафика')
)
plt.ylabel(None)
plt.show;
plt.draw()
plt.pause(0.001)
(profiles.query('device == "android"')
.groupby('channel')['user_id']
.count()
.plot(kind='pie',
subplots=True,
autopct='%1.0f%%',
colors = colors,
title='Распределение пользователей android по источникам трафика')
)
plt.ylabel(None)
plt.show;
plt.draw()
plt.pause(0.001)
(profiles.query('device == "pc"')
.groupby('channel')['user_id']
.count()
.plot(kind='pie',
subplots=True,
autopct='%1.0f%%',
colors = colors,
title='Распределение пользователей pc по источникам трафика')
)
plt.ylabel(None)
plt.show;
plt.draw()
plt.pause(0.001)
Снижение популярности tiptop и faceboom у пользователей приводит к снижению CAC
LTV пользователей всех платформ растет примерно в одной динамике, при этом pc ниже всех оставшихся платформ на протяжении всего двухнедельного лайфтайма, android iphone и mac примерно на одном уровнеLTV двух недель хорошо сочетается с графиком LTV -- платформа pc стабильно ниже конкурентов, периодически отрыв усиливается (конец июля, начало сентября)mac и iphone, медленнее всего для pc; наиболее дешево обошлись пользователи платформы pc, самыми дорогими оказались платформы mac и iphone, а пользователи android заняли промежуточное положениеpc окупаются в среднем к концу двух недельandroid, mac и iphone убыточны для приложения -- ROI меньше единицы по прошествии двух недель; это связано с большей популярностью tiptop у пользователей этих платформ в сравнении с пользователями pcROI двух недель показывает снижение экономической эффективности для всех когорт с приближением к текущей датеpc начала сентября и далее перестают окупатьсяraw, ltv, ltv_history, roi, roi_history = get_ltv(
profiles,
orders,
observation_date,
horizon_days,
dimensions=['region'],
ignore_horizon=False,
)
plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon_days, window=14)
Проверим, какие источники трафика для пользователей из США
(profiles.query('region == "united_states"')
.groupby('channel')['user_id']
.count()
.plot(kind='pie',
subplots=True,
autopct='%1.0f%%',
colors = colors,
title='Распределение пользователей США по источникам трафика')
)
plt.ylabel(None)
plt.show;
plt.draw()
plt.pause(0.001)
Несмотря на то, что большинство пользователей из США пришли из faceboom, влияние tiptop велико: 32 %
Проверим, какие источники трафика для пользователей из Великобритании
(profiles.query('region == "uk"')
.groupby('channel')['user_id']
.count()
.plot(kind='pie',
subplots=True,
autopct='%1.0f%%',
colors = colors,
title='Распределение пользователей Великобритании по источникам трафика')
)
plt.ylabel(None)
plt.show;
plt.draw()
plt.pause(0.001)
В Великобритании не привлекались пользователи tiptop, в этом и причина низкого CAC для этого региона; для гарантии проверим распределение для Германии
(profiles.query('region == "germany"')
.groupby('channel')['user_id']
.count()
.plot(kind='pie',
subplots=True,
autopct='%1.0f%%',
colors = colors,
title='Распределение пользователей Германии по источникам трафика')
)
plt.ylabel(None)
plt.show;
plt.draw()
plt.pause(0.001)
Практически идентичная картина с Великобританией, гипотеза подтвердилась
LTV и динамике LTV в явных лидерах пользователи из США; Германия, Великобритания и Франция примерно на одном уровне, однако Франция несколько проигрывает конкурентамtiptop , которая популярна в США, но не популярна в ЕвропеROI и его динамику, несмотря на низкие значения LTV пользователей из Европы, они стабильно окупаются уже на пятый-шестой день лайфтайма, а пользователи из США в сраднем не окупаются за две недели; пользователи из США окупались только до конца мая -- до взрывного роста их CACROI двух недель для европейских пользователей достигает значений на уровне 150 %raw, ltv, ltv_history, roi, roi_history = get_ltv(
profiles,
orders,
observation_date,
horizon_days,
dimensions=['channel'],
ignore_horizon=False,
)
plot_ltv_roi(ltv, ltv_history, roi, roi_history, horizon_days, window=14)
roi.sort_values(by=13, ascending=False)
| cohort_size | cac | 0.0 | 1.0 | 2.0 | 3.0 | 4.0 | 5.0 | 6.0 | 7.0 | 8.0 | 9.0 | 10.0 | 11.0 | 12.0 | 13.0 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| channel | ||||||||||||||||
| yrabbit | 4102 | 0.217 | 0.752 | 0.983 | 1.179 | 1.400 | 1.641 | 1.787 | 1.910 | 2.022 | 2.123 | 2.213 | 2.314 | 2.414 | 2.516 | 2.578 |
| mediatornado | 4167 | 0.219 | 0.628 | 0.875 | 1.127 | 1.341 | 1.517 | 1.675 | 1.774 | 1.911 | 2.009 | 2.107 | 2.217 | 2.321 | 2.403 | 2.437 |
| lambdamediaads | 2056 | 0.727 | 0.633 | 0.887 | 1.071 | 1.245 | 1.432 | 1.596 | 1.736 | 1.859 | 1.987 | 2.070 | 2.172 | 2.242 | 2.332 | 2.429 |
| leapbob | 8103 | 0.210 | 0.510 | 0.741 | 0.979 | 1.121 | 1.236 | 1.374 | 1.498 | 1.590 | 1.760 | 1.877 | 1.988 | 2.085 | 2.179 | 2.252 |
| rocketsuperads | 4251 | 0.417 | 0.497 | 0.763 | 0.949 | 1.092 | 1.235 | 1.368 | 1.531 | 1.646 | 1.768 | 1.893 | 1.992 | 2.071 | 2.153 | 2.237 |
| opplecreativemedia | 8139 | 0.250 | 0.389 | 0.527 | 0.687 | 0.827 | 0.965 | 1.065 | 1.161 | 1.239 | 1.315 | 1.388 | 1.457 | 1.507 | 1.581 | 1.637 |
| wahoonetbanner | 8082 | 0.602 | 0.318 | 0.484 | 0.630 | 0.737 | 0.859 | 0.946 | 1.050 | 1.127 | 1.205 | 1.272 | 1.350 | 1.411 | 1.478 | 1.526 |
| adnonsense | 3702 | 1.008 | 0.501 | 0.584 | 0.639 | 0.678 | 0.701 | 0.727 | 0.748 | 0.762 | 0.783 | 0.796 | 0.801 | 0.809 | 0.821 | 0.831 |
| faceboom | 27544 | 1.113 | 0.526 | 0.594 | 0.629 | 0.655 | 0.671 | 0.683 | 0.696 | 0.705 | 0.712 | 0.719 | 0.724 | 0.729 | 0.734 | 0.738 |
| tiptop | 18498 | 2.758 | 0.125 | 0.180 | 0.226 | 0.268 | 0.305 | 0.339 | 0.368 | 0.398 | 0.425 | 0.452 | 0.476 | 0.497 | 0.518 | 0.540 |
LTV, как и его динамика у канала lambdamediaads, на втором месте tiptop, хуже всего показатель у opplecreativemediaCAC практически постоянная, у rocketsuperads и yrabbit она даже падает, при этом источник tiptop постоянно повышает стоимость привлечения нового пользователя: с самого начала этот показатель был почти самым большим, а со временем вырос до огромных значений в сравнении с конкурентами -- необходимо уточнить, с чем это связано? Растет CPM / CPC? Прошлая рекламная кампания еще активна, а мы начинаем новую? Для точного ответа нужно понимать детали рекламных кампаний с этим источникомROI демонстрирует неэффективность привлечения по каналам tiptop, faceboom и adnonsense -- в среднем пользователи из этих источников не окупаются за двухнедельный лайфтайм; в динамике источник tiptop окупался до конца мая, но не позднее; adnonsense периодически пересекает границу рентабельности (в начале августа, сентября и октября)yrabbit, mediatornado и lambdamediaads -- пользователи из этих каналов окупаются в среднем уже на третий деньГипотеза того, что пользователи tiptop конвертируются в среднем лучше остальных, подтвердилась
Как соотносится количество новых пользователи из tiptop с объемом инвестиций в рекламу по этому каналу?
visits_tiptop = visits.query('channel == "tiptop"')
raw_ret, retention_tiptop, retention_history_tiptop = get_retention(
profiles,
visits_tiptop,
observation_date,
horizon_days,
dimensions=[],
ignore_horizon=False,
)
tiptop_pivot = (
retention_history_tiptop.reset_index()
.pivot_table(index='dt',
values='cohort_size',
aggfunc='sum'))
tiptop_pivot = filter_data(tiptop_pivot, 7)
fig, ax = plt.subplots()
fig.set_size_inches(10, 7)
ax.plot(tiptop_pivot.index, tiptop_pivot['cohort_size'], label = "tiptop")
ax.set_title('Динамика размера привлекаемых когорт по источнику tiptop \n')
ax.set_ylabel('Размер когорты, пользователей')
ax.legend()
plt.show;
plt.draw()
plt.pause(0.001)
fig, ax = plt.subplots()
fig.set_size_inches(10, 7)
for i in costs_fig['channel'].unique():
pivot_i = (costs_fig.query('channel == @i')
.pivot_table(index='dt',
values='costs',
aggfunc='sum'))
pivot_i = filter_data(pivot_i, 7)
ax.plot(pivot_i.index, pivot_i['costs'], label = i)
ax.set_title('Суточная динамика расходов по источникам трафика \n')
ax.set_ylabel('Расходы на трафик, у.е.')
ax.set_xlabel('День')
ax.legend()
plt.show;
plt.draw()
plt.pause(0.001)
tiptop, при этом смущает период начала августа-начала сентября: траты на рекламу сопоставимы с прошлым пиком или выше, а количество привлекаемых пользователей значительно нижеtiptop повышалась, либо в конце мая была изменена модель оплаты: например ранее была CPM, а после стала CPCROI ниже единицыROI двух недель показывает, что в мае маркетинговые инвестиции окупались, как и в середине июня, но тренд, намеченный в начале июня сохранился и продолжился с конца июня -- инвестиции стали убыточны; выход в плюс в середине июня объясняется сезонностью -- в этот период динамика LTV также демонстрирует ростandroid, mac и iphone убыточны для приложения -- ROI меньше единицы по прошествии двух недель; это связано с большей популярностью tiptop у пользователей этих платформ в сравнении с пользователями pcROI двух недель показывает снижение экономической эффективности для всех когорт с приближением к текущей датеpc начала сентября и далее перестают окупатьсяtiptop не популярен, поэтому нет негативного влияния на CAC европейских пользователейtiptop, faceboom и adnonsense -- в среднем пользователи из этих источников не окупаются за двухнедельный лайфтайм; в динамике это также стабильно, независимо от даты привлечения пользователей -- они не окупаются за две неделиtiptop -- колоссальный рост CAC с течением времени, необходимо уточнить детали рекламных кампаний: каким образом происходит оплата?faceboom на убыточность приложения: пользователи, привлеченные по этому каналу не окупаются, но маркетинговые траты на этот канал составляют примерно треть всех трат за периодtiptop и faceboom, вместо этого стоит рассмотреть размещение в yrabbit, mediatornado, lambdamediaads, leapbob, rocketsuperadsПредобработка данных
Исследовательский анализ данных
iphone, android, mac и pc, в абсолютном выражении у самых качественных по конверсии пользователей iphone, а в относительном -- mac, наименее качественные пользователи pcfaceboomАнализ маркетинга
83 % всего бюджета потрачено на два источника трафика -- tiptop и faceboom, при этом чуть больше половины всех средств ушло на продвижение через tiptoptiptop -- этот источник трафика демонстрирует стремительный рост затрат в течение рассматриваемого периодаtiptop и faceboom, при это для tiptop этот показатель в 2.5 раза выше в сравнении с faceboomleapbob, mediatornado и yrabbitCAC, требуемого для привлечения этих пользователей, tiptop и faceboom находятся в худшей половине, tiptop находится на последнем местеОценка рентабельности рекламы
ROI ниже единицыROI двух недель показывает, что в мае маркетинговые инвестиции окупались, как и в середине июня, но тренд, намеченный в начале июня сохранился и продолжился с конца июня -- инвестиции стали убыточны; выход в плюс в середине июня объясняется сезонностью -- в этот период динамика LTV также демонстрирует ростandroid, mac и iphone убыточны для приложения -- ROI меньше единицы по прошествии двух недель; это связано с большей популярностью tiptop у пользователей этих платформ в сравнении с пользователями pcROI двух недель показывает снижение экономической эффективности для всех когорт с приближением к текущей датеpc начала сентября и далее перестают окупатьсяtiptop не популярен, поэтому нет негативного влияния на CAC европейских пользователейtiptop, faceboom и adnonsense -- в среднем пользователи из этих источников не окупаются за двухнедельный лайфтайм; в динамике это также стабильно, независимо от даты привлечения пользователей -- они не окупаются за две неделиtiptop -- колоссальный рост CAC с течением времени, необходимо уточнить детали рекламных кампаний: каким образом происходит оплата?faceboom на убыточность приложения: пользователи, привлеченные по этому каналу не окупаются, но маркетинговые траты на этот канал составляют примерно треть всех трат за периодПричины неэффективности стратегии маркетинга
tiptop, на него ушла половина всех средств за рассматриваемый период, а также faceboom -- на него ушла треть всех средств периодаfaceboom были примерно на одном уровнеtiptop в течение рассматриваемого периода значительно возрасталиfaceboom обладают крайне низким удержанием, что вкупе с достаточно высоким CAC приводит к убыточности размещения, даже несмотря на самую лучшую конверсию по источникам трафикаtiptop средние или чуть выше среднего, но взрывной рост CAC привел к убыточности этого канала привлечения трафикаРекомендации
tiptop и faceboomCAC для платформы tiptop -- происходило ли повышение цены размещения?yrabbit, mediatornado, lambdamediaads, leapbob, rocketsuperads, показавших себя как экономически наиболее удачные источники трафика для приложения